iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
AI 自動化

協定、框架、架構:一條龍搞懂 AI Agent 是怎麼被造出來的系列 第 16 篇

Day 16:用 Python 實作 Plan-and-Execute:讓 Agent 完成多步任務

  • 分享至 

  • xImage
  •  

昨天我們先把 Plan 的格式定義好。

每一步要做什麼、要讀哪個檔案,都有固定結構,也會經過 Pydantic 和路徑規則驗證。

但昨天的 Plan 是我們自己填的。

今天才正式把 Planner 交給模型:

使用者任務
↓
Planner 產生 Plan
↓
驗證 Plan
↓
Executor 執行
↓
根據實際資料整理答案

這次不再加 LangGraph。

先用普通 Python 把 Planner、Executor 和結果彙整三個角色拆開,看清楚資料到底怎麼流動。

一、模型負責填格式,程式負責決定能不能執行

Day 15 已經用 Pydantic 定義好:

class PlanStep(BaseModel):
    model_config = ConfigDict(extra="forbid")

    purpose: str = Field(
        min_length=1,
        max_length=300,
    )

    path: str = Field(
        min_length=1,
        max_length=200,
    )


class Plan(BaseModel):
    model_config = ConfigDict(extra="forbid")

    steps: list[PlanStep] = Field(
        min_length=1,
        max_length=4,
    )

今天直接把同一份 Schema 交給 Ollama:

response = await llm.chat(
    messages,
    response_schema=Plan.model_json_schema(),
    max_tokens=1600,
    think=False,
)

plan = (
    Plan
    .model_validate_json(response.message.content)
    .validate_scope()
)

這樣不用維護兩套規格:

給模型看的輸出格式
Pydantic 實際驗證的格式

兩邊都來自:

Plan.model_json_schema()

但這裡要分清楚一件事。

Structured Output 能限制的是:

JSON 長什麼樣子

它不能保證:

Plan 的內容一定合理

例如模型完全可以產生:

{
  "steps": [
    {
      "purpose": "取得環境設定",
      "path": ".env.local"
    }
  ]
}

JSON 很漂亮。

Schema 也完全合法。

但這個路徑不允許讀取。

所以後面還是要經過:

validate_scope()

去檢查:

路徑是否允許
有沒有重複檔案
有沒有路徑穿越
步驟是否符合目前 Executor 的能力

也就是:

Structured Output
解決格式問題

Policy Validation
解決能不能執行

兩件事不能混在一起。

二、Planner、Executor 和答案彙整分開

完整流程變成:

https://ithelp.ithome.com.tw/upload/images/20260929/20161224TfN3NWxyN1.png

這裡刻意把責任拆開:

Planner
→ 決定要做哪些事

Validator
→ 決定 Plan 能不能執行

Executor
→ 真正呼叫 MCP Tool

Synthesizer
→ 根據 Observation 整理答案

模型提出:

我要讀 config.py

不代表它真的讀過。

真正的資料仍然只能從 Executor 執行後取得的 Observation 來。

這跟前面 ReAct 的原則一樣:

模型提出的是行動,工具回傳的才是實際結果。

三、先保存已經完成的工作

Plan-and-Execute 很快就會遇到一個問題。

假設第一輪 Plan 是:

Step 1 → config.py
Step 2 → llm.py

其中:

config.py → 成功
llm.py    → 失敗

如果重新規劃後又得到:

Step 1 → config.py
Step 2 → client.py

config.py 還需要再讀一次嗎?

這次我們把成功結果放進:

completed

並以檔案路徑作為索引。

概念上像:

completed = {
    "src/ironman/config.py": observation,
}

新的 Plan 如果再次出現相同路徑,就直接使用已完成的 Observation,不重新執行。

所以流程不是:

Re-plan
↓
全部重跑

而是:

Re-plan
↓
保留已完成工作
↓
只執行還沒完成的步驟

今天只是短時間讀檔,這樣已經夠用。

如果正式系統裡的檔案可能在執行期間被修改,就不能只記 path,還應該保存:

檔案版本
修改時間
內容雜湊

否則「剛才讀過」不一定代表「現在還是同一份內容」。

四、Re-plan 也不能無限重試

如果 Planner 一直產生錯誤 Plan:

規劃
↓
失敗
↓
重新規劃
↓
失敗
↓
重新規劃
↓
...

GPU 可以一直跑下去。

所以今天直接限制:

最多兩輪 Plan

也就是:

第一輪:原始 Plan

第二輪:一次修正 Plan

還是失敗就停止。

不會無限 Retry。

這裡跟 Day 13 的:

max_steps
tool_budget

其實是同一個觀念。

會自己重試的系統,就一定要有停止條件。

失敗也要成為明確結果,而不是一直跑到使用者手動把程式關掉。

五、第一次真的踩到 Structured Output 的坑

第一次跑 Planner 時,模型回傳:

content = ""

也就是空字串。

接著:

Plan.model_validate_json(...)

當然直接失敗。

這次沒有自己補一份 JSON 假裝成功。

原始失敗結果有保留下來:

outputs/day16_initial_failure.txt

最後針對這個結構化輸出步驟設定:

think=False

同時保留足夠的輸出 Token,才成功取得可以解析的 Plan。

這裡不能直接下結論說:

Structured Output 一律要關 Thinking

不同模型、版本與能力可能不同。

這只是這次本機模型實測遇到的行為。

真正要記的是:

如果 Planner 的輸出本身就是程式下一步要解析的資料,就必須驗證實際 response,而不是假設模型一定會按照預期輸出。

六、第二個坑:格式正確,但 Plan 不合理

後來又遇到另一種情況:

模型把同一個檔案拆成多個 Step。

例如:

{
  "steps": [
    {
      "purpose": "查看模型設定",
      "path": "src/ironman/config.py"
    },
    {
      "purpose": "確認端點設定",
      "path": "src/ironman/config.py"
    }
  ]
}

這次 JSON 完全正確。

Pydantic 也能解析。

問題是:

我們不希望同一個檔案在同一份 Plan 裡重複執行。

所以除了在 Prompt 裡明確要求:

每個檔案只安排一次

程式端仍然要檢查重複 path。

如果不符合規則,就進入受限的 Re-plan。

還是同一句:

Prompt 告訴模型規則
程式負責真的執行規則

七、真正跑起來

今天的入口很短:

import asyncio
import json

from ironman.llm import OllamaClient
from ironman.mcp_bridge import devbench
from ironman.planning import execute_plan


async def main() -> None:
    async with devbench() as tools, OllamaClient() as llm:
        result = await execute_plan(
            llm,
            tools,
        )

        print(
            json.dumps(
                result,
                ensure_ascii=False,
                indent=2,
            )
        )

        assert result["status"] == "completed"


if __name__ == "__main__":
    asyncio.run(main())

Bridge 仍然是實際讀取檔案的唯一入口。

也就是:

Planner
不能直接碰檔案

Executor
也不能繞過 MCP Bridge

所以即使模型 Plan 裡寫了:

.env.local

真正執行時還是會被現有的權限規則擋下來。

🧪 隔離環境實測

Day 16 程式實測結果

八、不要只看最後那段 Answer

這次結果裡會保留幾個重要欄位:

status
attempts
completed
answer

它們代表的事情不同。

attempts

Planner 總共規劃了幾次。

例如:

attempts = 1

代表第一份 Plan 就成功完成。

如果是:

attempts = 2

代表中間發生過一次 Re-plan。

completed

這是 Executor 真正成功取得 Observation 的項目。

它比 Planner 寫了什麼更重要。

Planner 可以說:

我要讀三個檔案

但 completed 才告訴我們:

實際成功讀到哪些資料

answer

最後才是模型根據 Observation 整理出的答案。

所以檢查順序應該是:

Plan 是否合理
↓
Executor 是否真的成功
↓
Observation 是否完整
↓
最後答案是否符合證據

不能只因為最後一段文字讀起來很順,就認為整個任務成功。

九、流程成功,也不代表內容一定正確

即使:

status = completed

還是只代表整個 Plan-and-Execute 流程正常跑完。

不代表模型最後的每一句整理都一定正確。

例如原始碼裡可能有一段註解:

# TODO: 未來改成從環境變數載入

模型如果沒有分清楚:

目前實作

和:

未來預告

就可能把 TODO 寫成已經存在的功能。

所以:

Executor 成功
≠
答案自動正確

後面加入 Reviewer 和評估流程,就是為了處理這一層。


昨天我們只有:

人工 Plan
↓
Validator

今天則完整跑成:

Task
↓
Planner
↓
Structured Plan
↓
Validator
↓
Executor
↓
Observation
↓
必要時 Re-plan
↓
Answer

到這裡,Plan-and-Execute 已經真的由模型負責規劃。

而且整套流程沒有使用 Agent Framework。

這也是我想先手刻一次的原因。

接下來再看框架時,就比較容易分辨:

哪些事情是 Agent 本來就必須處理的

哪些事情只是 Framework 幫我們包起來

明天進入 Day 17:

Google ADK 是什麼?建立第一個 ADK Agent。

看看模型、Session、執行流程開始交給 Framework 管理之後,程式會變成什麼樣子。


參考資料

Ollama Structured Outputs
https://docs.ollama.com/capabilities/structured-outputs

Ollama Thinking
https://docs.ollama.com/capabilities/thinking


上一篇
Day 15:ReAct 邊走邊想,Plan-and-Execute 先規劃再行動
下一篇
Day 17:Google ADK 是什麼?建立第一個 ADK Agent
系列文
協定、框架、架構:一條龍搞懂 AI Agent 是怎麼被造出來的 共 18 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言